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

RequestDoesSuccess status
POST /tasksCreate a task from {"title": "…", "due": "2026-10-31"}201 Created
GET /tasksList tasks; ?done=false filters200 OK
GET /tasks/{id}Get one task200 OK
POST /tasks/{id}/doneMark a task as done200 OK
DELETE /tasks/{id}Delete a task204 No Content

The project uses only the standard library. Create a folder, run go mod init example.com/tasks, and add the four files.

GO.MOD
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):

GO store.go
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:

GO api.go
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):

GO main.go
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:

GO api_test.go
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
Output
=== 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.514s

The 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:

  1. Add PATCH /tasks/{id} to change a task's title or due date, with the same validation as Create.
  2. Add ?sort=due to the list endpoint, ordering tasks with a due date first (lesson 29: slices.SortFunc with cmp.Or).
  3. Save tasks to a JSON file on every change and load them at startup (lessons 57 and 59). Watch out for concurrent writes.
  4. Give tasks UUIDs from the new uuid package instead of numbers (lesson 67).
  5. Require an API key in an Authorization header, as middleware (lesson 61), and test both the allowed and rejected cases.
  6. Write a small command-line client with flag subcommands — tasks add, tasks list, tasks done 3 — that talks to the API with net/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.

Congratulations on finishing the Go course! Test yourself with the quiz, practise with the exercises, and see the syllabus for a recap of everything you've covered.