Project: A Task Tracker API
Time to put the course together. In this project you'll build a small but complete JSON web API for tracking tasks: a thread-safe in-memory store, HTTP handlers with routing and validation, structured logging, graceful shutdown, and a test suite that exercises the whole thing. Every file below compiles together, and the tests at the end were run with Go 1.27.
What we're building
| Request | Does | Success status |
|---|---|---|
POST /tasks | Create a task from {"title": "…", "due": "2026-10-31"} | 201 Created |
GET /tasks | List tasks; ?done=false filters | 200 OK |
GET /tasks/{id} | Get one task | 200 OK |
POST /tasks/{id}/done | Mark a task as done | 200 OK |
DELETE /tasks/{id} | Delete a task | 204 No Content |
The project uses only the standard library. Create a folder, run go mod init example.com/tasks, and add the four files.
module example.com/tasks go 1.27
1. The store
The store owns the data and its mutex (lessons 36, 38 and 54). It knows nothing about HTTP, which keeps it easy to test and to replace with a database later. Errors callers need to recognise are sentinel values (lesson 44):
package main import ( "errors" "fmt" "maps" "slices" "strings" "sync" "time" ) var ( ErrNotFound = errors.New("task not found") ErrValidation = errors.New("invalid task") ) type Task struct { ID int `json:"id"` Title string `json:"title"` Due string `json:"due,omitempty"` // YYYY-MM-DD Done bool `json:"done"` Created time.Time `json:"created"` } type Store struct { mu sync.Mutex tasks map[int]Task nextID int now func() time.Time // replaceable in tests } func NewStore(now func() time.Time) *Store { return &Store{tasks: map[int]Task{}, now: now} } func (s *Store) Create(title, due string) (Task, error) { title = strings.TrimSpace(title) if title == "" { return Task{}, fmt.Errorf("%w: title is required", ErrValidation) } if len(title) > 200 { return Task{}, fmt.Errorf("%w: title is longer than 200 bytes", ErrValidation) } if due != "" { if _, err := time.Parse(time.DateOnly, due); err != nil { return Task{}, fmt.Errorf("%w: due must be a date like 2026-10-31", ErrValidation) } } s.mu.Lock() defer s.mu.Unlock() s.nextID++ t := Task{ID: s.nextID, Title: title, Due: due, Created: s.now()} s.tasks[t.ID] = t return t, nil } func (s *Store) Get(id int) (Task, error) { s.mu.Lock() defer s.mu.Unlock() t, ok := s.tasks[id] if !ok { return Task{}, ErrNotFound } return t, nil } // List returns tasks sorted by ID; done filters by status when not nil. func (s *Store) List(done *bool) []Task { s.mu.Lock() defer s.mu.Unlock() out := []Task{} // encodes as [] rather than null when empty for _, id := range slices.Sorted(maps.Keys(s.tasks)) { t := s.tasks[id] if done == nil || t.Done == *done { out = append(out, t) } } return out } func (s *Store) Complete(id int) (Task, error) { s.mu.Lock() defer s.mu.Unlock() t, ok := s.tasks[id] if !ok { return Task{}, ErrNotFound } t.Done = true s.tasks[id] = t return t, nil } func (s *Store) Delete(id int) error { s.mu.Lock() defer s.mu.Unlock() if _, ok := s.tasks[id]; !ok { return ErrNotFound } delete(s.tasks, id) return nil }
2. The HTTP layer
The API type turns HTTP requests into store calls and store results into JSON responses (lessons 59 and 61). One function, writeError, maps errors to status codes with errors.Is, so handlers stay short:
package main import ( "encoding/json" "errors" "io" "log/slog" "net/http" "strconv" ) type API struct { store *Store log *slog.Logger } func (a *API) Routes() http.Handler { mux := http.NewServeMux() mux.HandleFunc("POST /tasks", a.create) mux.HandleFunc("GET /tasks", a.list) mux.HandleFunc("GET /tasks/{id}", a.get) mux.HandleFunc("POST /tasks/{id}/done", a.complete) mux.HandleFunc("DELETE /tasks/{id}", a.delete) return a.logRequests(mux) } func (a *API) create(w http.ResponseWriter, r *http.Request) { var in struct { Title string `json:"title"` Due string `json:"due"` } dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20)) dec.DisallowUnknownFields() if err := dec.Decode(&in); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"}) return } t, err := a.store.Create(in.Title, in.Due) if err != nil { a.writeError(w, err) return } writeJSON(w, http.StatusCreated, t) } func (a *API) list(w http.ResponseWriter, r *http.Request) { var done *bool if v := r.URL.Query().Get("done"); v != "" { b, err := strconv.ParseBool(v) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "done must be true or false"}) return } done = &b } writeJSON(w, http.StatusOK, a.store.List(done)) } func (a *API) get(w http.ResponseWriter, r *http.Request) { id, ok := a.pathID(w, r) if !ok { return } t, err := a.store.Get(id) if err != nil { a.writeError(w, err) return } writeJSON(w, http.StatusOK, t) } func (a *API) complete(w http.ResponseWriter, r *http.Request) { id, ok := a.pathID(w, r) if !ok { return } t, err := a.store.Complete(id) if err != nil { a.writeError(w, err) return } writeJSON(w, http.StatusOK, t) } func (a *API) delete(w http.ResponseWriter, r *http.Request) { id, ok := a.pathID(w, r) if !ok { return } if err := a.store.Delete(id); err != nil { a.writeError(w, err) return } w.WriteHeader(http.StatusNoContent) } func (a *API) pathID(w http.ResponseWriter, r *http.Request) (int, bool) { id, err := strconv.Atoi(r.PathValue("id")) if err != nil || id < 1 { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id must be a positive number"}) return 0, false } return id, true } func (a *API) writeError(w http.ResponseWriter, err error) { switch { case errors.Is(err, ErrNotFound): writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) case errors.Is(err, ErrValidation): writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()}) default: a.log.Error("unexpected error", "err", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"}) } } func (a *API) logRequests(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { a.log.Info("request", "method", r.Method, "path", r.URL.Path) next.ServeHTTP(w, r) }) } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(v) }
3. The program
main wires everything together: a JSON logger, the store, an http.Server with timeouts, and graceful shutdown on Ctrl+C (lessons 56, 61 and 64). The port comes from an environment variable with a default (lesson 63):
package main import ( "context" "errors" "log/slog" "net/http" "os" "os/signal" "syscall" "time" ) func main() { logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) api := &API{store: NewStore(time.Now), log: logger} addr := ":" + envOr("PORT", "8080") srv := &http.Server{ Addr: addr, Handler: api.Routes(), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second, } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() go func() { logger.Info("listening", "addr", addr) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { logger.Error("server failed", "err", err) os.Exit(1) } }() <-ctx.Done() logger.Info("shutting down") shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := srv.Shutdown(shutdownCtx); err != nil { logger.Error("shutdown", "err", err) } } func envOr(key, fallback string) string { if v, ok := os.LookupEnv(key); ok { return v } return fallback }
Run it with go run . and try it from another terminal:
curl -i -X POST localhost:8080/tasks -d '{"title": "Write the report", "due": "2026-10-31"}'
curl localhost:8080/tasks
curl -X POST localhost:8080/tasks/1/done
curl -i -X DELETE localhost:8080/tasks/1
4. The tests
The tests drive the whole API through its http.Handler with httptest (lessons 61 and 65), using a fixed clock so the created times are predictable. A small helper sends a request and returns the status and body:
package main import ( "io" "log/slog" "net/http/httptest" "strings" "testing" "time" ) func newTestAPI() *API { clock := func() time.Time { return time.Date(2026, 10, 8, 9, 0, 0, 0, time.UTC) } return &API{store: NewStore(clock), log: slog.New(slog.NewTextHandler(io.Discard, nil))} } func send(t *testing.T, a *API, method, target, body string) (int, string) { t.Helper() rec := httptest.NewRecorder() a.Routes().ServeHTTP(rec, httptest.NewRequest(method, target, strings.NewReader(body))) return rec.Code, strings.TrimSpace(rec.Body.String()) } func TestTaskLifecycle(t *testing.T) { a := newTestAPI() steps := []struct { method, target, body string wantStatus int wantBody string }{ {"POST", "/tasks", `{"title":"Write report","due":"2026-10-31"}`, 201, `{"id":1,"title":"Write report","due":"2026-10-31","done":false,"created":"2026-10-08T09:00:00Z"}`}, {"POST", "/tasks", `{"title":"Book flights"}`, 201, `{"id":2,"title":"Book flights","done":false,"created":"2026-10-08T09:00:00Z"}`}, {"POST", "/tasks/1/done", "", 200, `{"id":1,"title":"Write report","due":"2026-10-31","done":true,"created":"2026-10-08T09:00:00Z"}`}, {"GET", "/tasks?done=false", "", 200, `[{"id":2,"title":"Book flights","done":false,"created":"2026-10-08T09:00:00Z"}]`}, {"DELETE", "/tasks/2", "", 204, ""}, {"GET", "/tasks/2", "", 404, `{"error":"task not found"}`}, } for _, s := range steps { status, body := send(t, a, s.method, s.target, s.body) if status != s.wantStatus || body != s.wantBody { t.Errorf("%s %s:\n got %d %s\nwant %d %s", s.method, s.target, status, body, s.wantStatus, s.wantBody) } } } func TestValidation(t *testing.T) { tests := []struct { name, body, wantBody string wantStatus int }{ {"empty title", `{"title":" "}`, `{"error":"invalid task: title is required"}`, 422}, {"bad date", `{"title":"x","due":"31/10/2026"}`, `{"error":"invalid task: due must be a date like 2026-10-31"}`, 422}, {"unknown field", `{"title":"x","priority":1}`, `{"error":"invalid JSON body"}`, 400}, {"not JSON", `title=x`, `{"error":"invalid JSON body"}`, 400}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { status, body := send(t, newTestAPI(), "POST", "/tasks", tt.body) if status != tt.wantStatus || body != tt.wantBody { t.Errorf("got %d %s, want %d %s", status, body, tt.wantStatus, tt.wantBody) } }) } } func TestBadID(t *testing.T) { status, body := send(t, newTestAPI(), "GET", "/tasks/abc", "") if status != 400 || !strings.Contains(body, "positive number") { t.Errorf("got %d %s", status, body) } } func TestConcurrentCreates(t *testing.T) { a := newTestAPI() done := make(chan struct{}) for range 50 { go func() { send(t, a, "POST", "/tasks", `{"title":"parallel"}`) done <- struct{}{} }() } for range 50 { <-done } if n := len(a.store.List(nil)); n != 50 { t.Errorf("got %d tasks, want 50", n) } }
go test -v -race
=== RUN TestTaskLifecycle
--- PASS: TestTaskLifecycle (0.00s)
=== RUN TestValidation
=== RUN TestValidation/empty_title
=== RUN TestValidation/bad_date
=== RUN TestValidation/unknown_field
=== RUN TestValidation/not_JSON
--- PASS: TestValidation (0.00s)
--- PASS: TestValidation/empty_title (0.00s)
--- PASS: TestValidation/bad_date (0.00s)
--- PASS: TestValidation/unknown_field (0.00s)
--- PASS: TestValidation/not_JSON (0.00s)
=== RUN TestBadID
--- PASS: TestBadID (0.00s)
=== RUN TestConcurrentCreates
--- PASS: TestConcurrentCreates (0.00s)
PASS
ok example.com/tasks 1.514sThe concurrency test runs fifty requests at once under the race detector, which confirms that the store's mutex really protects its map.
Challenges
Extend the project to practise what you've learned:
- Add
PATCH /tasks/{id}to change a task's title or due date, with the same validation asCreate. - Add
?sort=dueto the list endpoint, ordering tasks with a due date first (lesson 29:slices.SortFuncwithcmp.Or). - Save tasks to a JSON file on every change and load them at startup (lessons 57 and 59). Watch out for concurrent writes.
- Give tasks UUIDs from the new
uuidpackage instead of numbers (lesson 67). - Require an API key in an
Authorizationheader, as middleware (lesson 61), and test both the allowed and rejected cases. - Write a small command-line client with
flagsubcommands —tasks add,tasks list,tasks done 3— that talks to the API withnet/http(lessons 62 and 63).
Where to go next
You now know the whole language and the most important parts of its standard library. From here, try building a real tool you'll use yourself, read the source of a standard library package you've used (it's very readable), and explore the wider ecosystem: database access with database/sql and a driver, gRPC services, or cloud tooling. To store data properly, our SQL course pairs well with Go, and Git will keep your projects safe.